#!/usr/bin/python

# Attempt to import pyGTK/GTKv2.
# http://www.linuxjournal.com/article/6586
import sys
try:
    import pygtk
    pygtk.require("2.0")
except:
    pass
try:
    import gtk
    import gtk.glade
    import gobject
except:
    print "You need to install pyGTK or GTKv2"
    sys.exit(1)
import cairo
import pango

import time
import thread, threading
import math
import urllib2
import tempfile
import re
from PIL import Image

import gmaps
import downloader
import pygtkutil
import warper
# User controls.
import resizableimage
import logview
import progresstable


def update_gui():
    while gtk.events_pending():
        gtk.main_iteration(False)


def parse_angle(text, directions="NSEW"):
    """Parses angle roughly matching dms format.
    http://code.google.com/p/biogeomancer-core/wiki/LatLongParsingRequirements
    """

    text = text.decode('utf8')

    # + or - sign.
    result = re.search(
        ur"^\s* ([\-\+]?) \s* (.*) $",
        text, re.X | re.S | re.I)
    sign, text = result.groups()

    # Parse set of up to 3 numbers with DMS-style separators.
    dms, text = parse_angle_numbers(text)
    deg, min, sec = dms
    # Convert to floating point.
    if deg is None:
        return None
    else:
        deg = float(deg)
    if min is None:
        min = 0.0
    else:
        min = float(min)
        if abs(min) >= 60:
            return None
    if sec is None:
        sec = 0.0
    else:
        sec = float(sec)
        if abs(sec) >= 60:
            return None
    # Build value from components.
    value = deg + min / 60.0 + sec / (60.0 * 60.0)

    # Parse direction from remainder of string. Ensure there's nothing left.
    result = re.search(
        ur"^\s* ([%s])? \s* $" % directions,
        text, re.X | re.S | re.I)
    if result is None:
        # Stuff left over that we didn't expect.
        return None
    dir = result.group(1)

    # Check if sign needs to be inverted.
    if sign == '-':
        value *= -1
    if dir in ['S', 's', 'W', 'w']:
        value *= -1
    return value

def parse_angle_numbers(text):
    """Parses a set of up to three unsigned decimal numbers from a string.
    Supports DMS-style separators, but must be in order and complete up to the
    last component specified.

    Returns:
    A 2-tuple of:
      * A 3-tuple of decimal numbers as strings corresponding to degrees,
        minutes and seconds. Some values will be None if there was less than
        three numbers.
      * Any remaining text following the numbers.

    """
    deg = None
    min = None
    sec = None

    decimal_pattern = ur"(?: \d+ (?: \.\d*)? | \.\d+)"
    deg_pattern = ur"(?: deg | [d\u00b0])"
    min_pattern = ur"(?: min | [m'])"
    sec_pattern = ur"(?: sec | [s\"])"

    # Parse first numeric value.
    result = re.search(
        ur"^ (%s) \s* (.*)" % decimal_pattern,
        text, re.X | re.S | re.I)
    if result is None:
        return ((deg, min, sec), text)
    deg, text = result.groups()
    # Remove degrees sign if there is one.
    result = re.search(
        ur"^ (%s) \s* (.*)" % deg_pattern,
        text, re.X | re.S | re.I)
    if result is not None:
        text = result.group(2)
    # Parse second numeric value.
    result = re.search(
        ur"^ (%s) \s* (.*)" % decimal_pattern,
        text, re.X | re.S | re.I)
    if result is None:
        return ((deg, min, sec), text)
    min, text = result.groups()
    # Remove minutes sign if there is one.
    result = re.search(
        ur"^ (%s) \s* (.*)" % min_pattern,
        text, re.X | re.S | re.I)
    if result is not None:
        text = result.group(2)
    # Parse third numeric value.
    result = re.search(
        ur"^ (%s) \s* (.*)" % decimal_pattern,
        text, re.X | re.S | re.I)
    if result is None:
        return ((deg, min, sec), text)
    sec, text = result.groups()
    # Removes seconds sign if there is one.
    result = re.search(
        ur"^ (%s) \s* (.*)" % sec_pattern,
        text, re.X | re.S | re.I)
    if result is not None:
        text = result.group(2)
    return ((deg, min, sec), text)

def parse_latitude(text):
    return parse_angle(text, directions="NS")

def parse_longitude(text):
    return parse_angle(text, directions="EW")

def extract_pano_id_from_url(url):
    result = re.search(ur"[\&\?] panoid \= ([\w\-]+)", url, re.X | re.I)
    if result:
        return result.group(1)
    else:
        return None

def extract_lat_long_from_url(url):
    # Try looking for cbll, then ll, then sll in URL.
    # (streetview latlng, latlng, search latlng)
    params = ['cbll', 'll', 'sll']
    generic = ur"[\&\?] %s \= (\-?\d+ (?: \.\d+)?) , (\-?\d+ (?: \.\d+)?)"
    patterns = [generic % x for x in params]
    for pattern in patterns:
        result = re.search(pattern, url, re.X | re.I)
        if result:
            latitude = float(result.group(1))
            longitude = float(result.group(2))
            return gmaps.LatLng(latitude, longitude)
    return None


class Model:
    
    def __init__(self):
        pass


class MainWindow:
    
    def __init__(self, model):
        """Initialises main window."""

        # The data the application is working with.
        self.model = model
        # Load GUI from file.
        self.load_gui()
        # Set up collections of knowledge required by GUI.
        self.input_method_name = {
            0: 'address',
            1: 'url',
            2: 'latlong',
            3: 'image',
        }
        self.input_method_container = {
            'address': self.address_hbox,
            'url': self.url_hbox,
            'latlong': self.lat_long_hbox,
            'image': self.image_hbox,
        }
        self.step_expanders = [
            self.step1_expander,
            self.step2_expander,
        ]
        self.input_buttons = [
            self.address_search_button,
            self.url_search_button,
            self.lat_long_search_button,
            self.image_filechooserbutton,
        ]
        font_desc = pango.FontDescription("bold 12")
        self.step1_label.modify_font(font_desc)
        self.step2_label.modify_font(font_desc)
        # Set up initial state.
        self.initial_conditions()
        # Finally, make the main window visible.
        self.main_window.show()


    def load_gui(self):

        # Initiate GUI.
        self.gladefile = "streetplanets.glade"
        self.wTree = gtk.glade.XML(self.gladefile)

        # Track down all the widgets we need.
        self.elements = [
            'main_window',
            'step1_expander', 'step2_expander',
            'step1_label', 'step2_label',
            'input_method_vbox', 'pano_vbox', 'warper_hbox',

            # Parent element for each method's set of input controls.
            'input_method_combobox',
            'address_hbox', 'url_hbox', 'lat_long_hbox', 'image_hbox',
            #  Entries and buttons for different input methods.
            'address_entry', 'address_search_button',
            'url_entry', 'url_search_button',
            'latitude_entry', 'longitude_entry', 'lat_long_search_button',
            'image_filechooserbutton',
            # Progress bars and status boxes for required downloads.
            'search_vbox', 'pano_id_vbox', 'pano_details_vbox',
            'search_progressbar', 'search_textview',
            'pano_id_progressbar', 'pano_id_textview',
            'pano_details_progressbar', 'pano_details_textview',

            # Controls for previewing/downloading a panorama.
            'pano_download_hbox', 'pano_viewport', 'resolution_combobox',

            # Controls for doing planet transformation.
            'planet_viewport',
            'size_mode_combobox', 'width_entry', 'height_entry',
            'zoom_mode_combobox', 'radius_entry',
            'bg_mode_combobox',
            'offset_entry',
            'perspectify_checkbutton',
            'invert_x_checkbutton', 'invert_y_checkbutton',

            'log_frame',
            'step1_vbox',
            'pano_frame', 'planet_frame',
        ]
        # Add each widget up as a member variable.
        for element in self.elements:
            self.__dict__[element] = self.wTree.get_widget(element)

        # Add objects at run time.
        # Add LogView control for input.
        self.input_logview = logview.LogView()
        self.input_logview.show()
        self.log_frame.add(self.input_logview)
        # Add ListStore model for resolution options.
        self.resolution_liststore = gtk.ListStore(str)
        self.resolution_combobox.set_model(self.resolution_liststore)
        # Add a label for reporting on status of panorama download.
        self.pano_label = gtk.Label()
        self.pano_label.show()
        # Add a ResizableImage control for previewing the flat panorama.
        self.pano_image = resizableimage.ResizableImage()
        self.pano_image.show()
        # Add a ResizableImage control for previewing the planet image.
        self.planet_image = resizableimage.ResizableImage(enlarge=True)
        self.planet_image.show()
        # Add a progress bar control for generating planet preview.
        self.planet_progressbar = gtk.ProgressBar()
        self.planet_progressbar.show()

        # Connect signals.
        signals = {
            'on_main_window_destroy':
                self.on_main_window_destroy,
            'on_input_method_combobox_changed':
                self.on_input_method_combobox_changed,
            'on_step1_expander_activate':
                self.on_step_expander_activate,
            'on_step2_expander_activate':
                self.on_step_expander_activate,
            'on_address_search_button_clicked':
                self.on_address_search_button_clicked,
            'on_url_search_button_clicked':
                self.on_url_search_button_clicked,
            'on_lat_long_search_button_clicked':
                self.on_lat_long_search_button_clicked,
            'on_image_filechooserbutton_file_set':
                self.on_image_filechooserbutton_file_set,
            'on_download_button_clicked':
                self.on_download_button_clicked,
        }
        self.wTree.signal_autoconnect(signals)


    def initial_conditions(self):
        """Puts all the controls in correct initial state."""
        # Only show first expander.
        self.step1_expander.set_expanded(True)
        pygtkutil.change_packing(self.step1_expander, expand=True)
        self.step2_expander.set_expanded(False)
        pygtkutil.change_packing(self.step2_expander, expand=False)
        # Input method selection.
        self.input_method_combobox.set_active(0)
        self.show_input_method('address')
        # Download/save panorama controls.
        self.pano_download_hbox.show()
        self.pano_download_hbox.set_sensitive(False)

    #
    # GUI UTILITY FUNCTIONS
    #

    def show_input_method(self, inputMethod):
        """Shows only controls for specified inputMethod."""
        for name, container in self.input_method_container.iteritems():
            if inputMethod == name:
                container.show()
            else:
                container.hide()

    def set_input_buttons_sensitive(self, sensitive):
        """Enables or disables all input buttons."""
        for button in self.input_buttons:
            button.set_sensitive(sensitive)

    def set_pano_frame_widget(self, widget):
        self.pano_frame.foreach(self.pano_frame.remove)
        if widget:
            self.pano_frame.add(widget)

    def set_download_text(self, text):
        label = gtk.Label(text)
        label.show()
        self.set_pano_frame_widget(label)

    #
    # EVENT HANDLERS
    #

    def on_main_window_destroy(self, widget, data=None):
        gtk.main_quit()

    def on_input_method_combobox_changed(self, widget, data=None):
        index = widget.get_active()
        inputMethod = self.input_method_name[index]
        # Show only relevant input controls.
        self.show_input_method(inputMethod)
        # If local image method was selected, hide download controls.
        if inputMethod == 'image':
            self.pano_download_hbox.hide()
        else:
            self.pano_download_hbox.show()

    def on_step_expander_activate(self, widget, data=None):
        # Are we expanding or collapsing?
        # get_expanded() gives state before event occurred.
        expanding = not widget.get_expanded()
        if expanding:
            # Expanding one widget, collapse all others.
            for step_expander in self.step_expanders:
                if step_expander == widget:
                    # This step is expanding. Set packing to 'expand'.
                    pygtkutil.change_packing(step_expander, expand=True)
                else:
                    # Other steps - collapse.
                    step_expander.set_expanded(False)
                    pygtkutil.change_packing(step_expander, expand=False)
        else:
            for step_expander in self.step_expanders:
                # Make every step's packing the same so they're equal.
                pygtkutil.change_packing(step_expander, expand=True)

    def on_address_search_button_clicked(self, widget, data=None):
        # Load address from text entry.
        self.model.address = self.address_entry.get_text()
        # Clear status log.
        self.input_logview.clear()
        if self.model.address == '':
            self.input_logview.log('No address entered!', 'error')
        else:
            # Start the serious stuff - disable button and drop down.
            self.set_input_buttons_sensitive(False)
            # Download the information in another thread.
            thread = threading.Thread(target=self.search_from_address)
            thread.daemon = True
            thread.start()

    def on_url_search_button_clicked(self, widget, data=None):
        # Load address from text entry.
        url = self.url_entry.get_text()
        # Clear status log.
        self.input_logview.clear()
        self.input_logview.log('Looking for panorama ID in URL...')
        # Look for pano ID straight up in URL.
        panoId = extract_pano_id_from_url(url)
        if panoId is not None:
            self.model.panoId = panoId
            self.input_logview.log(panoId, 'success')
            # Disable buttons, start thread.
            self.set_input_buttons_sensitive(False)
            thread = threading.Thread(target=self.search_from_id)
            thread.daemon = True
            thread.start()
        else:
            self.input_logview.log('Looking for co-ordinates in URL...')
            # No match for pano ID, look for co-ordinates.
            location = extract_lat_long_from_url(url)
            if location is not None:
                self.model.location = location
                self.input_logview.log(str(location), 'success')
                # Disable buttons, start thread.
                thread = threading.Thread(target=self.search_from_location)
                thread.daemon = True
                thread.start()
            else:
                self.input_logview.log(
                    'Could not extract panorama ID or latitude/longitude ' \
                    'information from URL.', 'error')

    def on_lat_long_search_button_clicked(self, widget, data=None):
        # Clear status log.
        self.input_logview.clear()
        self.input_logview.log('Parsing input...')
        # Load user lat/long from text entry.
        latitude = parse_latitude(self.latitude_entry.get_text())
        longitude = parse_longitude(self.longitude_entry.get_text())

        # Validate input.
        if (latitude is None) and (longitude is None):
            self.input_logview.log('Invalid latitude and longitude.', 'error')
        elif latitude is None:
            self.input_logview.log('Invalid latitude.', 'error')
        elif longitude is None:
            self.input_logview.log('Invalid longitude.', 'error')
        elif abs(latitude) > 90:
            self.input_logview.log('Latitude cannot exceed 90.', 'error')
        elif abs(longitude) > 180:
            self.input_logview.log('Longitude cannot exceed 180.', 'error')
        else:
            self.model.location = gmaps.LatLng(latitude, longitude)
            self.input_logview.log(str(self.model.location), 'success')
            # Disable input buttons.
            self.set_input_buttons_sensitive(False)
            # Download the information in another thread.
            thread = threading.Thread(target=self.search_from_location)
            thread.daemon = True
            thread.start()

    def on_image_filechooserbutton_file_set(self, widget, data=None):
        # Update input log.
        self.input_logview.clear()
        self.input_logview.log('Loading file from disk...')
        # Get chosen filename.
        filename = self.image_filechooserbutton.get_filename()
        if filename is None or filename == '':
            self.input_logview.log('No file specified!', 'error')
            return
        # Remove previous control from frame.
        pano_frame_child = self.pano_frame.get_child()
        if pano_frame_child is not None:
            self.pano_frame.remove(pano_frame_child)
        # Insert image from file. Small delay so update GUI.
        update_gui()
        try:
            self.pano_image.set_from_file(filename)
        except Exception, ex:
            self.input_logview.log(str(ex), 'error')
            return
        self.pano_frame.add(self.pano_image)
        self.input_logview.log(filename, 'success')
        self.model.pano_file = filename
        self.load_pano_image()

    def on_download_button_clicked(self, widget, data=None):
        details = self.model.panoDetails
        resolution = self.resolution_combobox.get_active()
        rows, cols = details.tilesAtZoom(resolution)
        # Store this information in the model.
        self.model.resolution = resolution
        self.model.tileRows = rows
        self.model.tileCols = cols
        # Remove previous control from frame.
        pano_frame_child = self.pano_frame.get_child()
        if pano_frame_child is not None:
            self.pano_frame.remove(pano_frame_child)
        # Add ProgressTable control for downloading.
        self.download_progresstable = progresstable.ProgressTable(rows, cols)
        self.download_progresstable.show()
        self.pano_frame.add(self.download_progresstable)
        # Download the panorama in another thread.
        thread = threading.Thread(target=self.download_panorama)
        thread.daemon = True
        thread.start()

    def load_pano_image(self):
        # Remove whatever's in the preview frame and insert the progress bar.
        self.planet_progressbar.set_text("Generating preview...")
        self.planet_progressbar.set_fraction(0)
        child = self.planet_frame.get_child()
        if child is not None:
            self.planet_frame.remove(child)
        self.planet_frame.add(self.planet_progressbar)
        # Create a warping manager.
        manager = warper.Manager()
        # Load image into warper.
        manager.LoadImage(self.model.pano_file)
        # Choose warper settings and start warping.
        settings = warper.Settings()
        settings.custom_width = 512
        settings.custom_height = 512
        manager.TransformBegin(settings, 100000, 0.01)
        # Save to model.
        self.model.warper_settings = settings
        self.model.warper_manager = manager
        # Run preview generator in thread.
        thread = threading.Thread(target=self.update_warp_preview)
        thread.daemon = True
        thread.start()

    #
    # THREADED DOWNLOAD FUNCTIONS.
    # Critical sections that modify the GUI must be surrounded with
    # gtk.gdk.threads_enter() and gtk.gdk.threads_leave().
    #

    def search_from_address(self):
        self.get_location_from_address()

        gtk.gdk.threads_enter()
        self.set_input_buttons_sensitive(True)
        gtk.gdk.threads_leave()

    def search_from_location(self):
        self.get_id_from_location()

        gtk.gdk.threads_enter()
        self.set_input_buttons_sensitive(True)
        gtk.gdk.threads_leave()

    def search_from_id(self):
        self.get_details_from_id()

        gtk.gdk.threads_enter()
        self.set_input_buttons_sensitive(True)
        gtk.gdk.threads_leave()

    def get_location_from_address(self):
        key = self.model.key
        address = self.model.address

        gtk.gdk.threads_enter()
        self.input_logview.log('Searching for location...')
        gtk.gdk.threads_leave()
        try:
            location = gmaps.getLocation(key, address)
        except Exception, ex:
            gtk.gdk.threads_enter()
            self.input_logview.log(str(ex), 'error')
            gtk.gdk.threads_leave()
            return
        else:
            self.model.location = location
            gtk.gdk.threads_enter()
            self.input_logview.log(str(location), 'success')
            gtk.gdk.threads_leave()
            # Move on to next step.
            self.get_id_from_location()

    def get_id_from_location(self):
        location = self.model.location

        gtk.gdk.threads_enter()
        self.input_logview.log('Locating nearest panorama...')
        gtk.gdk.threads_leave()
        try:
            id = gmaps.getNearestPanoramaID(location)
        except Exception, ex:
            gtk.gdk.threads_enter()
            self.input_logview.log(str(ex), 'error')
            gtk.gdk.threads_leave()
            return
        else:
            self.model.panoId = id
            gtk.gdk.threads_enter()
            self.input_logview.log(id, 'success')
            gtk.gdk.threads_leave()
            self.get_details_from_id()

    def get_details_from_id(self):
        id = self.model.panoId
        gtk.gdk.threads_enter()
        self.input_logview.log('Getting panorama details...')
        gtk.gdk.threads_leave()
        try:
            details = gmaps.getPanoramaDetails(id)
        except Exception, ex:
            gtk.gdk.threads_enter()
            self.input_logview.log(str(ex), 'error')
            gtk.gdk.threads_leave()
        else:
            self.model.panoDetails = details
            gtk.gdk.threads_enter()
            self.input_logview.log(str(details), 'success')
            gtk.gdk.threads_leave()
            # Finished obtaining panorama information!
            # Set resolution options.
            resolutions = self.model.panoDetails.zoomLevelNames()
            self.model.resolutions = resolutions
            gtk.gdk.threads_enter()
            self.resolution_liststore.clear()
            for resolution in resolutions:
                self.resolution_liststore.append([resolution])
            index = min(len(resolutions) - 1, 2)
            self.resolution_combobox.set_active(index)
            # Re-enable download controls.
            self.pano_download_hbox.set_sensitive(True)
            gtk.gdk.threads_leave()

    def download_panorama(self):
        """Downloads all panorama tiles sequentially.
        Blocks until all of these threads have finished.

        """
        resolution = self.model.resolution
        details = self.model.panoDetails
        # Download panorama tiles.
        self.model.tiles = {}
        for y in range(self.model.tileRows):
            for x in range(self.model.tileCols):
                self.download_tile(details.id, resolution, x, y)
        # Inform the user that the tiles are being stuck together.
        gtk.gdk.threads_enter()
        self.pano_label.set_text("Compositing image...")
        self.pano_frame.remove(self.download_progresstable)
        self.pano_frame.add(self.pano_label)
        gtk.gdk.threads_leave()
        # Assemble tiles.
        image = Image.new('RGB', details.sizeAtZoom(resolution), 'black')
        for y in range(self.model.tileRows):
            for x in range(self.model.tileCols):
                try:
                    tile = self.model.tiles[(x, y)]
                except KeyError:
                    raise Exception("Failed to download all tiles")
                image.paste(tile, (details.tileWidth * x,
                                   details.tileHeight * y))
        # Save the image for working in C++.
        self.pano_label.set_text("Saving image to temporary file...")
        image.save("panorama.png")
        self.model.pano_file = "panorama.png"
        # Load the image in the user interface.
        gtk.gdk.threads_enter()
        self.pano_image.set_from_PIL(image)
        self.pano_frame.remove(self.pano_label)
        self.pano_frame.add(self.pano_image)
        gtk.gdk.threads_leave()

        # Start up the warper!
        self.load_pano_image()

    def download_tile(self, id, zoom, x, y, block=1024):
        """Downloads a tile, updating the correct progress bar."""
        progressbar = self.download_progresstable.get_progressbar(x, y)
        gtk.gdk.threads_enter()
        progressbar.set_text("Connecting...")
        gtk.gdk.threads_leave()

        def update_progressbar(progress, total):
            """Updates the progress of downloading a tile."""
            fraction = float(progress) / total
            percent = "%d%%" % (fraction * 100)
            gtk.gdk.threads_enter()
            progressbar.set_fraction(fraction)
            progressbar.set_text(percent)
            gtk.gdk.threads_leave()

        tile = gmaps.getPanoramaTile(id, zoom, x, y,
                                     progress=update_progressbar)
        self.model.tiles[(x, y)] = tile

    def update_warp_preview(self):
        """Updates the progress bar while generating a preview."""
        manager = self.model.warper_manager
        progressbar = self.planet_progressbar
        while not manager.TransformFinished():
            fraction = manager.TransformFraction()
            percent = "%d%%" % (fraction * 100)
            gtk.gdk.threads_enter()
            progressbar.set_fraction(fraction)
            progressbar.set_text(percent)
            gtk.gdk.threads_leave()
            manager.TransformStep()
        # Save image and load into control.
        gtk.gdk.threads_enter()
        progressbar.set_fraction(1)
        progressbar.set_text("Loading preview...")
        gtk.gdk.threads_leave()
        manager.SaveOutput("preview.png", "png")
        # TODO Need locks here to use gdk functions?
        self.planet_image.set_from_file("preview.png")
        # Remove previous control.
        gtk.gdk.threads_enter()
        child = self.planet_frame.get_child()
        if child is not None:
            self.planet_frame.remove(child)
        self.planet_frame.add(self.planet_image)
        gtk.gdk.threads_leave()


def main(argv):

    model = Model()
    # My API key. Hope it's ok to use this for all users.
    model.key = "ABQIAAAACz69gin6BxJBW21hNzxwBhT2yXp_ZAY8_ufC3CFXhHIE1NvwkxQjo0KGnh3InyeP5gr9wPZZSnmaow"

    gtk.gdk.threads_init()
    gui = MainWindow(model)
    warper.GtkInit()

    gtk.main()

    warper.GtkDestroy()

if __name__ == '__main__':
    main(sys.argv)

